Repository navigation
feat(linear): mint OAuth tokens via AgentCore Identity Token Vault, and latch revoked credentials (#809, #812) - #831
Conversation
…ault (#809) Slice 1 of RFC #249 Phase 1. Adds the stack-owned half of the Linear OAuth token vault: a LinearIdentityVault construct that provisions the AgentCore workload identity backing 3LO (USER_FEDERATION) token issuance, plus a grantMintToken() helper for the token data-plane calls. - New construct linear-identity-vault.ts + bundled custom-resource handler (workload-identity create/update/delete; synchronous, so onEvent-only, no isComplete poller). Mirrors the registry.ts Provider-framework idiom. - Registers the return-URL allowlist with BOTH the CLI localhost loopback and an optional hosted onboarding URL (spike F9/F11: allowlist enforced, entries coexist, caller picks per-call). - Wired into agent.ts + LinearIntegration behind the context flag enableLinearIdentityVault (additive, default-off; default synth byte-for-byte unchanged, verified 0 vault occurrences). Webhook processor gets the token data-plane grant + LINEAR_VAULT_ENABLED / LINEAR_WORKLOAD_IDENTITY_NAME env only when the vault is present. - Bootstrap least-privilege: map Custom::LinearWorkloadIdentity to lambda:InvokeFunction; synth-coverage test asserts the flag-on path is fully bootstrap-covered (deploy-safe), matching the tool-gateway pattern. - Adds @aws-sdk/client-bedrock-agentcore-control (control-plane sibling of the already-present client-bedrock-agentcore data-plane client). Token resolution still reads the per-workspace SM token; wiring the resolver onto the vault path is the next slice. No behavior change with the flag off.
…lback (#809) Slice 3a of RFC #249 Phase 1 — the Lambda-side (trusted) token resolver. - New linear-vault-token.ts: resolveLinearTokenViaVault mints a USER-BOUND workload token (GetWorkloadAccessTokenForUserId, user id linear-workspace-<orgId> per the registry-table design) then exchanges it (GetResourceOauth2Token, USER_FEDERATION). Returns null — never throws — on a consent-required response OR any API error, so the caller degrades to the SM token rather than blocking a task. Matches the Phase-0 spike (F2/F7). - resolveLinearOauthToken: when LINEAR_VAULT_ENABLED and the workspace has a provider_name (vault-onboarded), try the vault first and fall through to the existing Secrets-Manager path on null. Absent provider_name / flag off skips the vault entirely, so SM-only installs synth + behave unchanged. - RegistryRow gains provider_name; parseRegistryRow now maps it. - Tests: vault module (happy / consent-required→null / no-WAT / error→null) and resolver (vault-used-no-SM-read, vault-null→SM-fallback, no-provider_name→skip). Agent-side reactions token + the CLI setup path that writes provider_name are the next slices. No behavior change with the flag off.
Slice 3b of RFC #249 Phase 1 — the agent (reactions) token path. - config.py _resolve_linear_token_via_vault: self-mints through boto3 (get_workload_access_token_for_user_id → get_resource_oauth2_token, USER_FEDERATION) so it behaves identically on the AgentCore and ECS substrates — both authorize against the agent session role's IAM rather than the AgentCore-injected WorkloadAccessToken header. User id is linear-workspace-<orgId> (matches the Lambda resolver + registry-table convention). Returns '' — never raises — on consent-required / error, so resolve_linear_api_token falls through to the Secrets-Manager token. - resolve_linear_api_token tries the vault first when LINEAR_VAULT_ENABLED and the task carries linear_provider_name + linear_workspace_id; SM-only installs and the flag-off path are untouched. Updated the stale 2.0a-parked docstring (the spike showed USER_FEDERATION no longer has the bug that parked it). - Tests: mint-and-cache, consent-required→SM, vault-error→SM, no-provider→skip, flag-off→skip. Full agent quality gate green (1588 pass, 82.8% cov). The CDK wiring that stamps linear_provider_name/linear_workspace_id into channel_metadata + grants the agent session role is the next step. No behavior change with the flag off.
…grant (#809) Slice 3c of RFC #249 Phase 1 — makes the agent-side vault path actually fire. - ResolvedLinearToken gains providerName; resolveLinearOauthToken returns it on both the vault and SM paths (carried through so the agent can attempt the vault even when the Lambda resolved via SM). - linear-webhook-processor stamps linear_provider_name + linear_workspace_id into channel_metadata for the single-task path, and persists linear_provider_name into the orchestration release_context so released sub-issue children inherit it (workspace id already rides the existing credentials_ref stamp — no duplicate). - orchestration-store/release thread linear_provider_name through the persisted ReleaseContext + release params. - agent.ts: resolve enableLinearIdentityVault early; set LINEAR_VAULT_ENABLED + LINEAR_WORKLOAD_IDENTITY_NAME on the agent runtime env (both substrates) and grant the agent session role the token data-plane calls — only under the flag. Workload name is one shared const so env + construct can't drift. - Tests: agent-stack gate (default synth omits vault + grant + env; flag-on gets the grant + env). Touched suites green (webhook-processor, orchestration store/release, agent stack). No behavior change with the flag off.
Slice 4 of RFC #249 Phase 1 - the CLI path that makes a workspace vault-onboardable (writes provider_name, which is what activates the runtime vault path built in slice 3). - New cli/src/linear-vault.ts helpers: - upsertLinearCredentialProvider: create (idempotent, update on conflict) the CustomOauth2 provider from the workspace's Linear app client id/secret; returns the fixed vault callback URL to register on the Linear app (spike F6). - beginVaultConsent: drive the 3LO USER_FEDERATION round-trip - user-bound workload token then get-resource-oauth2-token with customParameters actor=app + prompt=consent (spike F1); returns the authorizationUrl plus a poll() that yields the minted token once consent completes server-side. - Deterministic linearVaultProviderName / linearVaultUserId matching the runtime resolvers. - New 'bgagent linear vault-setup <slug>' subcommand: layers the vault onto an already-SM-onboarded workspace (reads client id/secret + workspace id from the existing registry row / SM secret), creates the provider, prints the callback URL to register, opens the consent browser, polls for the token, then records provider_name on the registry row. Localhost return URL (already on the workload allowlist); consent completes at the AgentCore callback, polled - no localhost listener needed. - Adds @aws-sdk/client-bedrock-agentcore (data-plane) as a direct cli dep. - Tests: provider create + idempotent conflict-update, consent needed-poll-token, already-consented shortcut, neither-token-nor-url error. Full CLI suite green (758). Additive - no existing command changed.
`bgagent linear vault-setup maguireb` reported an already-onboarded workspace as "not onboarded". The filtered Scan passed `Limit: 1`, but DynamoDB applies `Limit` to the items READ *before* the FilterExpression runs — so it read one arbitrary row (demo-abca), filtered it out, and returned nothing. Live-caught on a two-workspace registry; reproduced with the AWS CLI (Limit 1 => ScannedCount 1, Count 0; no Limit => Count 1). - Extract the lookup into an exported `findWorkspaceRowBySlug` (matching the file's existing exported-for-test idiom) with no `Limit`, and match the slug explicitly rather than trusting Items[0]. - Tests: finds a workspace that is NOT the first row scanned (the broken case), asserts no `Limit` is passed, and still returns undefined when genuinely absent. - Verified against the live dev registry: maguireb + demo-abca both resolve, a bogus slug still does not. NOTE (pre-existing, not changed here): `findReusableOauthAppCredentials` has the same Limit-with-filter shape, and its test asserts `Limit: 1` — so a registry whose first scanned row is inactive would wrongly report "no active workspace". Left alone as out of scope for this issue.
…ready-exists (#809) AgentCore reports a duplicate name as a ValidationException, not the ConflictException the name suggests. Verified live against both APIs: CreateOauth2CredentialProvider -> "Credential provider with name: … already exists" CreateWorkloadIdentity -> "WorkloadIdentity with name '…' already exists." Both arrive as ValidationException, so the idempotent create-then-update paths rethrew instead of updating. Two real failures fixed: - `bgagent linear vault-setup` aborted on a re-run ("… already exists") instead of updating the provider in place. Live-caught. - The CDK custom-resource handler would have failed the NEXT deploy: CloudFormation sends an Update, the handler re-issues CreateWorkloadIdentity, and the unhandled duplicate error rolls the stack back. Caught by deliberately re-creating the existing workload identity rather than assuming the guard worked. Both sites now share the same predicate shape (name === 'ValidationException' && /already exists/i), keeping ConflictException accepted in case the service tightens this later, and still surfacing GENUINE validation errors (bad endpoint, bad return URL) rather than silently treating them as "exists". Tests: duplicate-ValidationException updates in place, and a genuine ValidationException still fails — for both the CLI helper and the CDK handler.
) Live-caught on dev: with the vault enabled and a provider_name on the workspace, the webhook processor's vault call was denied — not authorized to perform: bedrock-agentcore:GetWorkloadAccessTokenForUserId on resource: …:workload-identity-directory/default grantMintToken scoped both actions to the NAMED workload-identity ARN, but GetWorkloadAccessTokenForUserId authorizes against the workload-identity DIRECTORY. The unit test asserted the ARN we chose, not the one the service requires, so only a live run could surface it. - grantMintToken now grants the directory ARN (keeping the named identity alongside, in case the service tightens later); GetResourceOauth2Token is scoped to this account's token-vault oauth2 credential providers (names are created at onboarding time, so not known at synth); adds GetSecretValue on `bedrock-agentcore-identity!*`, which the vault reads through the caller when exchanging (ADR-016 P1 notes the same grant). Still no wildcard resource and no control-plane lifecycle actions. - ECS substrate parity — the same class as the F-2 memory-grant bug. The ECS container has its OWN baseEnvironment and task role, so the AgentCore runtime env/grant never reached it: the ECS agent would silently stay on Secrets Manager even with the vault on. EcsAgentCluster now takes an optional linearIdentityVault and wires BOTH the env (LINEAR_VAULT_ENABLED / LINEAR_WORKLOAD_IDENTITY_NAME) and the task-role grant, mirroring the toolGateway/agentMemory props. The vault construct moved earlier in agent.ts so it exists before the ECS cluster; the runtime-role grant stays where `runtime` is defined. - Tests: the construct test now asserts the SERVICE-REQUIRED resources (directory ARN, credential-provider ARN, identity secret) and still proves least-privilege; new ECS parity test with positive + negative proof, mirroring the F-2 guard. Fail-safe behaviour confirmed live throughout: the denied vault call fell back to Secrets Manager and the event degraded gracefully rather than crashing.
Second live AccessDenied after the workload-directory fix: not authorized to perform: bedrock-agentcore:GetResourceOauth2Token on resource: …:token-vault/default GetResourceOauth2Token authorizes against the token VAULT itself, not only the oauth2credentialprovider sub-path that was granted. Add the bare token-vault/default ARN alongside the per-provider path (kept so the grant still holds if the service moves to per-provider authorization). Test asserts both.
…#809) With IAM fixed, the runtime vault call succeeded but reported "requires consent" for a workspace whose grant WAS cached — every resolve silently degraded to the Secrets-Manager fallback. AgentCore keys a cached grant by the full token-request shape, customParameters included. Isolated live against the same user + provider: with customParameters {actor: app, prompt: consent} -> accessToken returned without them -> authorizationUrl ("needs consent") So the runtime resolvers must send the IDENTICAL parameter set that `bgagent linear vault-setup` used at consent time. Both resolvers now do: - cdk/src/handlers/shared/linear-vault-token.ts: new exported LINEAR_VAULT_CUSTOM_PARAMS, passed on GetResourceOauth2Token. - agent/src/config.py: mirrored _LINEAR_VAULT_CUSTOM_PARAMS (the two are kept in sync by comment, like the scopes list). Tests assert the parameters on both sides, with the reason recorded so a future "these look redundant on a read" cleanup does not silently reintroduce the cache miss. Neither the Phase-0 spike nor unit tests could surface this — it only appears when retrieving an already-consented grant.
The consent flow never completed. `vault-setup` opened the browser and polled `GetResourceOauth2Token`, but AgentCore's post-consent bounce was unhandled: nothing listened on the return URL (the browser 404'd) and the federation session was never finalized, so the poll kept getting an `authorizationUrl` as if consent had not happened and would have spun until timeout. The real flow — already documented in this repo's own oauth-callback-server.ts (lines 103-107, from the parked Phase-2.0a work) — is: Linear consent → AgentCore's callback (AWS exchanges the code) → AgentCore redirects the browser to `resourceOauth2ReturnUrl?session_id=…` → the caller captures that session_id and calls CompleteResourceTokenAuth. This also CORRECTS spike finding F10, which had called the return URL "cosmetic" — it is load-bearing. The spike reached that wrong conclusion because it never completed a consent. - New `finalizeVaultConsent()` wrapping CompleteResourceTokenAuth for the per-workspace user id; `beginVaultConsent` now also returns the `sessionUri`. - vault-setup starts `awaitOauthCallback()` (the existing helper `linear setup` already uses) BEFORE opening the browser, requires the `agentcore` callback kind, finalizes, then fetches the token. Rejects a `direct-oauth` callback with a pointer to `linear setup`, and the timeout message no longer blames consent. - Idempotent re-runs: probe the CACHED grant before forcing a fresh authorization, so re-running on a healthy workspace is a no-browser no-op instead of dragging the operator through consent to re-record a provider name. - Tests: sessionUri is exposed, finalization targets the right user + session, a finalization failure propagates rather than leaving the caller polling, and the cached re-run makes no forced round trip. Verified end-to-end against the live maguireb workspace: `bgagent linear vault-setup maguireb` now completes with no browser and records provider_name. The CompleteResourceTokenAuth call itself was live-proven earlier (it is what minted the token that made the runtime vault path work). Full CLI suite green (766).
…809) Slice 2 of RFC #249 Phase 1. The consent flow previously required a listener on the CLI's own localhost, which only works when the browser and the CLI share a machine — on a cloud desktop, SSH box, or container the redirect dead-ends and the workspace cannot be onboarded at all. - New LinearVaultConsentPage: private S3 bucket + CloudFront with Origin Access Control (same shape as ScreenshotBucket), serving one self-contained static page that reads `session_id` from the query string and shows it for the operator to paste back. Wired behind the existing `enableLinearIdentityVault` gate; its URL is registered on the workload identity's return-URL allowlist alongside the localhost loopback (both coexist — the CLI picks per call) and published as the `LinearVaultConsentUrl` output. - CLI: `--hosted` selects that URL (failing closed with a clear message if the stack does not publish it), and `--session <sessionUri>` finishes a consent that already happened in the browser — no listener at all. WHY THE PAGE IS STATIC. Finalizing needs CompleteResourceTokenAuth. Doing that on the page would mean a public, unauthenticated endpoint that completes OAuth sessions for whoever calls it. Instead the page only DISPLAYS the id and the CLI finalizes with the operator's own AWS credentials, so the privileged step stays with a principal we can authorize. (This supersedes an earlier note in the spike doc that called the return URL "cosmetic" — it is load-bearing, but display-only.) - The session id is untrusted query input reflected onto the page, so it is assigned via `textContent` only. A test asserts no markup-writing sink (innerHTML/outerHTML/document.write/insertAdjacentHTML) and no eval appears anywhere in the rendered page, that the page is fully self-contained (no external script/style/font/URL), and that it degrades to a clear message when opened without a session_id. - Bootstrap least-privilege: map Custom::CDKBucketDeployment, and the synth-coverage guard now asserts EVERY CFN type the gated path adds is mapped and covered (not just the vault's own), so a future gated resource cannot slip in unmapped. - Stack gate tests extended: default synth still omits the page + output; flag-on publishes LinearVaultConsentUrl and ships the deployment.
…arned gotchas (#809) Marks P1 built + live-verified (flag-gated, SM fallback retained) and adds a Phase-1 implementation-notes section. The notes are properties of AgentCore Identity rather than of Linear, so they apply to the Jira/Slack/GitHub ChannelCredentials in P7 — and none of them are reachable from a unit test: - The data plane authorizes against surprising resources: the workload-identity DIRECTORY (not the named identity) and the token VAULT (not the credential provider sub-path). Both surfaced only as a live AccessDenied, because a unit test can only assert the ARN you chose. Confirms the GetSecretValue on bedrock-agentcore-identity!* that this ADR already anticipated. - A cached grant is keyed by the whole token request INCLUDING customParameters, so resolving without the set used at consent time reports 'consent required' despite a valid grant and silently degrades to the fallback. - Consent must be finalized with CompleteResourceTokenAuth using the session_id from the return-URL bounce; the return URL is load-bearing, not cosmetic. - Revocation changes shape on the vault path (authorization URL, not invalid_grant), which is why detection needs #812. - Two deliberate deviations from the P1 text: a provider per workspace (grant isolation) and the agent self-minting on both substrates. - What Phase 1 does NOT deliver: per-user token attribution (workspace bot identity; P6 still open), and it does not stop a vendor revoking a grant. Starlight mirror regenerated; docs build + link check green.
Synth failed with cdk-nag ERRORs (IAM4 managed policy, IAM5 wildcard S3 grants, L1 runtime) on CDK's BucketDeployment handler. The suppressions were attached to this construct, but that handler is a SINGLETON created at STACK scope, so suppressing on `this` never reached it. Resolve the singleton by id prefix (`Custom::CDKBucketDeployment*`) rather than hardcoding CDK's version-dependent hash, which would silently stop matching on a CDK bump and re-break synth with an error that points at framework code. Verified live on backgroundagent-dev: synth passes nag, the page serves 200 over CloudFront, the origin bucket is private (direct S3 GET 403, BPA all-true), the workload identity allowlist carries BOTH return URLs, and the CLI resolves LinearVaultConsentUrl for --hosted.
) The page's delivery machinery (bucket, policy, auto-delete custom resource, distribution, OAC, and CDK's BucketDeployment singleton with its AWS-CLI layer, role and policy) is ~10 resources. Carried in the root stack that took backgroundagent-dev from 486 to 496 against CloudFormation's hard 500-per-stack limit — spending most of the remaining headroom on a landing page. Nesting gives those resources their own budget and costs the parent a single AWS::CloudFormation::Stack, the same trade AgentRegistryStack already makes for the registry. Root synth is back to 487; the nested template holds 13. - Bootstrap coverage follows the resources: the synth-coverage guard now walks the nested stack too, since CFN creates nested resources with the same execution role and an unmapped one fails deploy exactly like a root one. - Gate test asserts the nesting (root must NOT carry Custom::CDKBucketDeployment) so a future inline regression is caught rather than silently eating headroom. Verified live on backgroundagent-dev: the migration completed, the page serves 200 from the new distribution, and the workload identity's return-URL allowlist reconciled to the new CloudFront URL by itself — which also confirms the duplicate-name Update path added earlier works in production.
…809) The existing assertions prove a dangerous sink is ABSENT. They cannot prove the page WORKS: a typo in an element id or in the query-parameter name would sail past every grep and only surface in a browser, on the one flow an operator runs once. Run the page's inline script under vm against a minimal DOM shim, and assert behaviour: - session_id is extracted and rendered, along with the command to run - a HOSTILE id ('"><img src=x onerror=alert(1)>') survives verbatim as text and touches NO markup sink — the shim records any innerHTML/outerHTML write, so this demonstrates the XSS property instead of implying it - with no session_id the page explains itself and removes the empty slots Also live-checked against the deployed distribution: the page serves 200 with a session_id query string attached and the bytes are identical to the no-query response, which is correct — the id is read client-side from window.location, so the HTML never varies and the cache key legitimately excludes the query string.
The previous commit shipped with eslint failing (@typescript-eslint/no-require-imports
on `require('vm')`). CI's mutation guard would have rejected it — I committed
without re-reading the lint result. Import vm at the top of the file instead.
…flow (#809) `bgagent linear app-template` printed only the localhost callback, so an operator following it and then running `vault-setup` hit Linear's 'Invalid redirect_uri parameter for the application' with nothing in the output pointing at the cause. Live-caught during onboarding. The vault flow needs a SECOND callback on the Linear app — the provider callback, whose id is minted when the credential provider is created, so it cannot be printed before that exists. The template now says exactly that where the URLs are listed, and the gotcha list names the misleading error it produces. `--vault-callback-url` renders a complete template once the provider exists (additive: localhost stays, or `bgagent linear setup` breaks). Note the three URLs are sourced differently: the localhost callback is a CLI constant, the hosted consent page URL IS a stack output (LinearVaultConsentUrl, read by --hosted), and the provider callback is neither — it is runtime output from provider creation, which is why no template can carry it.
…iling tasks (#812) A revoked Linear grant was detected perfectly and then acted on not at all: the operator found out because a user's task silently did nothing, and every later event re-detected the same dead grant with no dedup. `markWorkspaceRevoked` already existed and was already careful (conditioned on `installed_at`, so a verdict can never revoke the SUCCESSOR installation) — but it was permanently inert, because every token-resolving role held read-only registry access, the conditional write failed AccessDenied, and the failure was deliberately swallowed so a diagnosis could not break token resolution. The feature read as implemented while doing nothing. - Grant the webhook processor registry WRITE and pass the recorder through, so the latch actually lands. It is the right holder: it is the path a revoked workspace hits on every event. - `markWorkspaceRevoked` now returns WHETHER it latched. That boolean is the dedup key — only the caller whose conditional write applied announces, so one revocation produces one alert rather than one per inbound event. - New `linear-revocation-alert.ts` publishes that one announcement to the existing operational SNS topic, naming the workspace and the exact recovery command. SNS is deliberately the channel: the dead credential is Linear's own, so a Linear comment cannot report it — that is precisely the path that no longer works. - Vault parity: a revoked grant surfaces on the AgentCore path as "consent required" rather than `invalid_grant`, so a detector keyed on the error shape would go quiet exactly when a workspace moved onto the vault. The vault resolver now returns a TYPED result instead of collapsing "needs consent" and "throttled" into one null (the silent-success-masking shape the repo lints against), and the resolver reports it — but only when NO path can produce a token, so a vault-dead/SM-alive workspace is not marked revoked and taken offline. - Announcing stays best-effort and never throws; the latch has already landed, so `platform doctor` can explain the state even if notification fails. Two things the work surfaced: - The CMK-encrypted topic needs `kms:GenerateDataKey*`/`Decrypt` as well as `sns:Publish`. `grantPublish` does not grant the key, and since announcing is best-effort the failure would have been swallowed — a new dormant feature in place of the one being fixed. - The added grants pushed the processor role past IAM's inline-policy size limit, so CDK now spills an `AWS::IAM::ManagedPolicy` overflow policy. Mapped it in the bootstrap action map; caught by tightening the synth-coverage guard to reject ANY unmapped type on the gated path, root or nested. Full CDK suite green (4189 tests, 205 suites).
…t invalid_grant (#812) The revocation branch could never run. It tested `error === 'invalid_grant'` — what RFC 6749 specifies — and Linear does not send that. Linear answers a dead refresh token with HTTP 400 and: { "error": "invalid_request", "error_description": "Refresh token revoked" } { "error": "invalid_request", "error_description": "Invalid refresh token" } so every real revocation was filed as a generic `failure`. The permanent-rejection path never executed, the registry was never marked, and no operator was ever notified: detection that existed and could not fire. This is a SECOND, independent reason the feature was dormant — fixing the missing registry-write grant alone would not have made it work. Found by live testing, not review: unit tests mocked the token endpoint with the error code the test author assumed. Confirmed two ways — against the token-lineage logs from the #807 investigation (maguireb's real death was `invalid_request` + "Refresh token revoked"), and by deliberately driving a refresh with a bogus token through the deployed Lambda. `isRefreshTokenRejection` now accepts `invalid_grant` (in case Linear aligns with the RFC) OR a 400 whose description names the refresh token. `invalid_request` alone is deliberately NOT enough — it is also what a malformed request, i.e. our own bug, returns, and marking a workspace revoked for that would take a working workspace offline. Tests cover both observed payloads, the RFC form, a generic `invalid_request`, and transient 5xx/429.
…hree I wired (#812) Live test caught this: the permanent-rejection branch ran, logged "workspace requires re-onboarding" — and still did not latch or announce, because the recorder was threaded through call sites and the discovering caller was not one of them. Seven modules resolve Linear tokens inside the webhook processor; I had wired three. The one that found it was `linear-feedback`, which is a likely discoverer precisely because posting a reply is often the first thing needing a token. Threading it per-caller is the wrong shape: every future caller has to remember, and forgetting is silent. The resolver now builds the recorder itself from the DynamoDB client and table name it already holds, so every path — present and future — is covered. A caller-supplied recorder still wins, which is what tests use. Gated on `LINEAR_REVOCATION_RECORDING`, set ONLY on the webhook processor role that holds registry write. Everywhere else the conditional write would fail AccessDenied and be swallowed — the inert-but-looks-implemented state this issue exists to remove, and the reason the resolver deliberately never defaulted this before. Also breaks a would-be import cycle: the alert module no longer imports the resolver. The resolver owns the latch and calls a pure SNS `announceRevocation`, which declares its own input type. Tests follow that split — dedup is asserted on the latch in the resolver suite, message content on the announcement. Full CDK suite green (4192 tests, 205 suites).
…-back (#809) The hosted consent page only fixed the SECOND onboarding step. `vault-setup` layers onto an already-onboarded workspace, and the prerequisite `bgagent linear setup` still required a localhost listener — so on a cloud desktop, SSH box, or container you were blocked at step 1 and never reached the part that had been fixed. Half a feature: the localhost dependency was moved, not removed. - The consent page now serves BOTH flows. The vault 3LO bounce arrives with `session_id`; the direct OAuth redirect arrives with `code` (+ `state`). One registered URL therefore REPLACES the localhost callback instead of merely supplementing it. - `bgagent linear setup <slug> --hosted` sends the redirect to that page and stops, printing what to paste back. `--code <code>` finishes the exchange. - New `linear-pending-consent.ts` carries the PKCE verifier + `state` between those two invocations. The code alone cannot complete a PKCE exchange, and `state` still has to be checked for CSRF. The verifier is a ONE-TIME SECRET, so the entry is 0600, consumed on read (including on refusal), rejected past a 15-minute TTL, and keyed per workspace so two onboardings cannot clobber each other. - `app-template` can now list the hosted page (`--hosted-consent-url`) and, when it cannot, says plainly that localhost will not work on a cloud desktop and names the flag that does — the instructions have to match reality or they send an operator into the "Invalid redirect_uri" dead-end this issue already produced once. Localhost remains the default for same-machine use; nothing about that path changed. Tests: page behaviour executed under vm for the code branch (including a hostile code rendered as text, and session_id winning when both are present) and the full pending-consent contract. CDK 4195 / CLI 780 green.
The consent page is served from a GENERATED CloudFront domain, and with --hosted that URL is registered as a Callback URL on the Linear app. It changed twice during development — the domain is stable only while the distribution is, and the distribution is replaced whenever the consent page is recreated (flag toggled off and on, nested stack recreated). A stale registration presents as the same 'Invalid redirect_uri' dead-end that already cost an onboarding attempt once, with nothing pointing at the cause. Both --hosted paths now print the URL with that caveat and the remedy: Linear accepts several callback URLs, so adding the new one is safe and the stale entry is harmless. Surfacing it beats an operator rediscovering it. A stable custom domain would remove the caveat entirely but needs a hosted zone and certificate — out of scope here, worth noting as the real fix.
…nding them (#809) `app-template` is the FIRST command an operator runs, and it required `--hosted-consent-url` — a CloudFormation output — plus a second callback URL that AgentCore mints at provider-create time. Both were things the operator had to go and find before the command that explains onboarding would tell them anything useful. It now resolves both itself: - the hosted consent page from the stack's LinearVaultConsentUrl output - the vault provider callback via GetOauth2CredentialProvider, given --slug Every lookup is best-effort. An unconfigured CLI, absent credentials, an undeployed stack, a flag-off deploy, and a not-yet-created provider all degrade to a NOT FOUND line that says what is missing and how to get it, so the command still renders offline. The flags remain as overrides, and --offline skips the lookups entirely. Each URL is now labelled with the command that redirects to it. Registering the wrong subset presents as an opaque Linear "Invalid redirect_uri", and an unlabelled list gave no way to tell which entry a failing command wanted — the exact dead-end that cost one onboarding attempt.
`Application name` was hardcoded to `bgagent`, so anyone running ABCA under their own branding had to notice the template was wrong and edit around it. `--app-name` now sets it, and the GitHub username Linear requires for actor=app is DERIVED from that name — one choice instead of two identifiers to keep consistent. `--bot-name` still overrides. The name is sanitized to something GitHub accepts (lowercase, single inner hyphens, 39 chars) and falls back to the default when a name has no usable characters at all, because an empty username field fails on Linear's side with the misleading "Invalid redirect_uri" error rather than naming the real problem. That fallback warns on stderr, since the template claims the username follows the app name and would otherwise be quietly lying. The template now also states that renaming does NOT change the trigger phrase. `parseCommentTrigger` matches a fixed `@bgagent` token that is independent of the app's name (verified against real workspace comments: app-authored comments carry the display handle derived from the application name, while the trigger token is plain text in the body). Without saying so, renaming yields an agent that looks right and answers nothing. Annotations are pinned to a column so operator-chosen values of differing lengths do not leave a ragged edge.
…ired (#809) The app template and setup guide both asserted that a "GitHub username" field and the app's Webhooks toggle are REQUIRED for actor=app, and blamed a blank username for Linear's "Invalid redirect_uri parameter for the application" error. Neither claim holds up: - Linear's OAuth documentation describes no GitHub-username field, and its `Application` GraphQL type exposes only clientId, description, developer, developerUrl, id, imageUrl and name. - ABCA's events arrive through the WORKSPACE webhook the operator creates separately (`bgagent linear webhook-info`), not the app's own toggle. The template already conceded the webhook URL is "unused by the OAuth dance" while still calling it required. The cost of the wrong attribution is real: it sends operators to fix a field that may not exist while the actual cause — a callback URL that is not registered on the app — stays broken. That cause is the one seen in practice, including for the AgentCore vault, whose provider callback is a SECOND URL the app must list. So the error's guidance now points at the registered-URL list (all URLs present, one line each, no wildcards), the username line is marked "only if your form shows this field", and the webhook note explains what actually delivers events. Also records that actor=app and the `admin` scope are mutually exclusive, which Linear documents and the template did not mention. Tests now assert the corrected facts and that the two false REQUIRED claims stay gone, so they cannot creep back.
) Checked the template field-by-field against the real app-creation form. It was both longer than anyone would read and wrong in ways that cost onboarding attempts: - "GitHub username" does not exist. The form is icon, application name, developer name, developer URL, description, redirect URIs. Removed the field, the --bot-name flag, and the name-to-username derivation behind it. - The field is "Redirect URIs" ("All OAuth redirect URIs, separated with newlines"), not "Callback URLs" — an operator scanning for our label would not find it. - The webhook URL was printed as https://example.com/placeholder alongside "you do NOT need to subscribe to any events", which is true of the OAuth dance alone and leaves an app that can never deliver an event. It now prints the real endpoint, derived from the configured API URL (no AWS call), and asks for Issues + Comments. - Webhooks CAN be configured on the app; the previous commit's claim that ABCA does not use the app-level toggle was wrong. Either the app's own webhook or a workspace webhook works, since the receiver verifies whichever signing secret it is given. Adds the trap that actually produced "Invalid redirect_uri" here: redirect URIs are matched as exact strings, so a missing trailing slash fails — the hosted consent URL ends in one. Also flags that ticking Issues without Comments silently disables the comment trigger while labels keep working, which is the kind of half-broken state nobody thinks to check. Two `setup` paths still said "Subscribe to: Issues", contradicting webhook-info. Both now name Comments and why. Net: 55 lines to 45, every URL real, every claim checked.
Reverts the previous commit's claim that the OAuth app's own webhook toggle is a valid place to configure ABCA. Checked against the running deployment: the live subscription is a WORKSPACE webhook (Issue + Comment, enabled) and the app's webhook is off. That is the configuration in use, and the only one verified. Worse, the previous commit printed ABCA's endpoint under the app's "Webhook URL" field. Following that alongside the documented workspace webhook yields two subscriptions to one endpoint: every event delivered twice, under two different signing secrets, while ABCA stores one per workspace — so the duplicate both doubles the work and fails to verify. Now stated as a trap, since pasting the URL in both places looks harmless. The endpoint is still resolved and printed, but labelled as the workspace webhook to create, so nobody has to go and find it. Also trimmed the trap list back under the length budget the rewrite set. Length is a real defect here: the list had grown to the point an operator stopped reading and left the webhook URL blank because an earlier version called it a placeholder.
…ace one (#809) One webhook, set up while creating the app, instead of an app plus a separate workspace subscription in a later step. Nothing in the receiver cares which surface delivered the event: it routes on the payload's `organizationId` and verifies the HMAC against whichever signing secret was stored for that workspace, so the app's own secret works unchanged. This also removes a whole failure class from onboarding — the per-workspace signing secret that a second `linear setup` run could overwrite, leaving the first workspace returning 401. The template prints the real endpoint in the app's Webhook URL field, asks for Issues + Comments, and says to keep the signing secret for `setup`. The guide now treats the app-level webhook as the path and a workspace webhook as the alternative — explicitly an either/or, since configuring both delivers every event twice under two secrets with only one able to verify. Multi-workspace note: an app webhook has one signing secret shared by every workspace the app is installed on, where workspace webhooks get one each. Both work, because the secret is stored per workspace either way.
…ide (#809) Two spots still told operators to "rely on the Issues + Comments webhook events (step 4)" and to "keep only the workspace webhook", which contradicts the app-level webhook the template now configures. Both now name the App events checkboxes as the thing to untick and say to keep the webhook itself enabled with the Issues + Comments data-change events.
Addressed at
|
An audit of the comments this branch adds turned up several that assert the opposite of what the code now does, plus one rendering defect the source hides. Factually wrong, now fixed: - The `markWorkspaceRevoked` call site claimed the revoked-marker is "OPT-IN, not defaulted" and that "no stack grants it write", eleven lines under the line that defaults it — and the write grant plus `LINEAR_REVOCATION_RECORDING` now ship in `linear-integration.ts`. The block also directly contradicted the two lines appended beneath it. - Step 1b claimed "the vault NEVER blocks token resolution". A row latched on the vault inference returns after the re-probe, so Secrets Manager is never consulted. - `resolveViaVault` carried two docblocks; the dead first one described a `string | null` return the type no longer has. - The vault-token header and `workspaceUserId` both presented `linear-workspace-<orgId>` as the live subject. It is the pre-#809 fallback — fresh installs bind the slug-derived id recorded at consent time. That function is now named `legacyWorkspaceUserId`, so the one fact a reader needs is in the name instead of a paragraph above it; its test pinned the same wrong framing ("matches registry-table convention") and now says what the pin is actually for. - Two docblocks sat on the wrong declaration: the `resolveLinearOauthToken` contract on `ResolvedLinearToken`, and the `VAULT_CONSENT_REVOCATION_REASON` description on the type above it. - The locally-declared `AnnounceableRevocation` cited an import cycle. `import type` is erased and no `import/no-cycle` rule is configured; the real reason is that the alert module depends on nothing of the resolver's. Stale: the cache-key literals still said "keep in sync with" their three peers. That is now enforced by `contracts/constants.json` and the parity test, so they point at the contract instead. A cross-reference to `workspaceUserId` "in config.py" named a symbol that never existed there — the agent inlines the form. De-duplicated: the announce-before-latch rationale appeared five times and the `LINEAR_REVOCATION_RECORDING` rationale three; each is now stated once where the mechanism lives, with pointers elsewhere. Dropped an intra-branch commit SHA and the bug-history retellings that stop meaning anything after squash-merge. Every comment recording a non-inferable fact is kept verbatim — AgentCore's `customParameters` cache key, Linear's non-RFC error body, the `installed_at` conditioning, the nosemgrep justifications. The resolver drops from 40% comment lines to 38%, against a 39% house norm in `cdk/src`; no unique fact was removed. Fixed while rendering the copy: the operator alert printed the same `bgagent linear setup` command twice on the vault path, and a `.filter(line => line !== '')` stripped every intentional blank line, collapsing the email into one block. Both were invisible in source and green under `toContain`; the new tests pin the command count and the paragraph breaks, and both were mutation-proven to fail against the old rendering. Also strengthened the refresh test, which asserted PutSecretValue was *called* but not what it wrote — persisting the pre-rotation bundle left it green, which is the failure mode that makes a grant die on its first refresh.
…s missed The first pass covered 12 files (1,595 of 2,685 added comment lines). This one covers the remaining 34, and re-sweeps all 46 for the cross-cutting categories. Wrong: - `linear-identity-provisioning` credited "the return URL is mandatory" to spike finding F8. That is F7; F8 is "a hosted https return URL works identically". Both now cited, along with F9 and F11. - `linear-vault-consent-page` pinned "The root `AgentStack` was at 486" — an undated reading of a number that moves every PR, so it is unfalsifiable prose that will eventually read as wrong. States the constraint instead. - Its two onboarding bullets both named `bgagent linear setup`, making the distinction they draw invisible. They are two redirect legs of one command. Stale — `vault-setup` and `--hosted` were removed when onboarding was consolidated, but five references still used them as current: - `agent.test.ts` claimed the published output is what lets `vault-setup --hosted` find the consent page. Neither the command nor the flag exists (`--hosted-consent-url` does). - `cli/src/linear-vault.ts` twice cited "a second `vault-setup` run". - `linear-pending-consent.test.ts` used `setup --hosted` in its file header and in a test name. The negative assertions that check `vault-setup` does NOT appear in operator output are correct and were left alone. Duplication, and the one place it was worth fixing structurally: five parallel builder signatures in `linear-webhook-processor.ts` each carried a byte-identical six-line inline type for `resolved` under a byte-identical four-line comment — 50 lines restating one fact five times. Replaced by a named `BuilderResolvedToken`, so the invariant the comment warned about (never narrow this per site, the callers pass the whole resolver result) is now enforced by the type instead of by prose. Narration: dropped "this PR" from `check-constants-sync.test.ts` and `linear-oauth-resolver.test.ts`, and the fourth restatement of the recorded-vs-derived subject rationale in `config.py` is now a pointer. The `fb1e007b` reference in `ecs-agent-cluster.test.ts` is kept — that commit is on main, so it stays resolvable. Checked and deliberately left: `config.py`'s "the vault never blocks a task" is true there (it reads no registry row and has no latch, unlike the Lambda resolver, where the same sentence was wrong and was corrected in the previous commit).
…-revocation-alerts # Conflicts: # cdk/test/stacks/agent.test.ts
…-pr' into feat/812-credential-revocation-alerts
scottschreckengaust
left a comment
There was a problem hiding this comment.
LGTM - consider a followup to address comments
…base Rebasing onto main (12c9b63) surfaced three collisions with work that merged after this branch was authored. None were textual conflicts — git merged the affected files cleanly and produced a build that failed. 1. `allowTestInvoke: false` on the DELETE integration. #854 stripped the API Gateway console test-invoke Lambda permissions to reclaim CloudFormation resources under the 500-resource ceiling, and added a guard asserting none are emitted. This route predates that convention, so it re-introduced one and failed the guard 6x (once per compute_type x enableToolGateway variant). The two sibling routes in this same construct already pass the option; now all three agree. 2. Attributed-Lambda count 46 -> 47. The original a19f16d bumped 45 -> 46 for RemoveWorkspaceFn, but main independently reached 46, so `git rebase` dropped the commit as "patch contents already upstream" — identical text, different reason. The textual change survived; the intent did not. With this branch's extra Lambda the correct value is 47. 3. Solution user agent on the new handler's SDK clients. linear-remove-workspace.ts constructed `new DynamoDBClient({})` and `new SecretsManagerClient({})` directly, which drops the solution user agent (#319). Now built through makeDocClient()/makeClient(), matching linear-link.ts and linear-webhook.ts. Also resolved the LINEAR_SETUP_GUIDE.md conflict from #831: kept its new "Vault-managed workspaces" section and dropped the duplicated webhook/ uninstall sentence, which this branch had already relocated into the parent "Removing a workspace" section. Starlight mirror regenerated. Refs #306
Conflicts with #831 (Linear OAuth Token Vault), which touched the same three files. All three were additive on both sides; resolved as unions. - `cdk/src/constructs/ecs-agent-cluster.ts` — imports. This branch's model constants plus main's `LinearIdentityVault`. - `cdk/src/stacks/agent.ts` — the lambda-microvm `platform_config` block. Both sides add fields, so both are kept, but NOT verbatim: main's side calls `haikuInferenceProfileId(bedrockGeoRegion)`, and that helper no longer exists here — it was removed when the two duplicate haiku-id paths were collapsed into one. Taking main's line as written would not compile. Resolved to `inferenceProfileId(bedrockGeoRegion, PLATFORM_DEFAULT_AUX_MODEL_ID)`, which is the same value through the surviving helper, and the vault fields are kept unchanged. - `cli/src/platform-doctor.ts` — three hunks: the `linear-auth-health` import, and the `Promise.all` destructuring plus its call list. The last two must stay positionally aligned, so both were extended in the same order; verified all ten names now line up with their outputs. Also fixed a `{@link haikuInferenceProfileId}` in `bedrock-models.ts` left dangling by that same helper removal — the merge is what surfaced it, since main's live call site was the only other reference. `yarn.lock` moves with main (+42, `@aws-sdk/client-sns` for #831's revocation alerts). Checked for semantic drift rather than trusting a clean textual merge: the restored "creates exactly 21 DynamoDB tables" guard is byte-identical to main's at the same line, so #831 added no table and the merge did not take a stale side of it. Suites after merge: 4499 CDK, 948 CLI, 1782 agent — `mise run build` clean.
…ed resolver (aws-samples#306) (aws-samples#681) * feat(cli): bgagent linear remove-workspace + DELETE route + fail-closed resolver (aws-samples#306) Add a `bgagent linear remove-workspace <slug>` command that deregisters a Linear workspace, replacing the manual DDB + Secrets Manager surgery that removal previously required. CLI (cli/src/commands/linear.ts): new subcommand mirroring add-workspace / update-webhook-secret UX — slug validation + a "type the slug to confirm" prompt (skipped by --yes). Delegates all writes to the backend via a new DELETE call so DDB/Secrets Manager grants stay on the API role, not on every CLI user (same pattern as `link`). Flags: --purge, --keep-mappings, --yes. Backend: new flat handler cdk/src/handlers/linear-remove-workspace.ts behind DELETE /v1/linear/workspaces/{slug} (route + Lambda wired in cdk/src/constructs/linear-integration.ts). Cognito-authenticated, admin-only (caller must match the recorded installed_by_platform_user_id). Default is a SOFT removal: flip the registry row to status=revoked (audit trail preserved) and delete the bgagent-linear-oauth-<slug> secret; --purge deletes the row outright. Secret deletion is idempotent (ResourceNotFoundException swallowed, other SM errors rethrown). Optional project-mapping cleanup keyed on linear_workspace_id. Fail-closed resolver: the OAuth resolver already rejects any non-active registry status (cdk/src/handlers/shared/linear-oauth-resolver.ts) — so a revoked workspace can no longer resolve a token or route webhooks the instant this returns. Added an adversarial test proving a revoked slug is rejected WITHOUT ever reading the secret, plus an active-control case. Docs: rewrote the "Removing a workspace" section of the Linear setup guide (Starlight mirror regenerated) to lead with the command and keep the manual DDB steps as a fallback. Security: no secrets logged (slug/workspace_id/booleans only); reuses existing AWS SDK clients; no new dependencies. SAST clean on new files. Closes aws-samples#306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(aws-samples#306): loud+recoverable partial teardown, phase logging, coverage gaps Review follow-up (self-review agents on PR aws-samples#681): - Secret-delete failure is no longer masked: a non-idempotent SM error (e.g. AccessDenied) after the row is revoked now returns a distinct 500 SECRET_DELETE_FAILED and writes a durable secret_deletion_failed / orphaned_oauth_secret_arn marker on the registry row, so the leaked credential is discoverable (a naive retry 404s at the active-scan and would never re-attempt the delete). ResourceNotFoundException stays idempotent (200). - Top-level catch + mapping-cleanup loop now log the failing phase + workspace id / per-page progress so on-call can locate an orphaned secret or half-cleaned mapping table from the request id. - Tests: SECRET_DELETE_FAILED path + marker write; FilterExpression pins the status='active' fail-closed filter to the handler (not the mock); paginated mapping cleanup across LastEvaluatedKey; missing oauth_secret_arn skip; CLI confirmation-prompt abort/proceed; secret_deleted:false CLI output; api-client query-string mapping (purge / keep_mappings snake_case). Relates to aws-samples#306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * docs(aws-samples#306): clarify remove-workspace Lambda timeout rationale Comment-only: the 30s timeout is higher than the 3s default the link/webhook handlers use, not "higher than the other request handlers" (the webhook processor also uses 30s). Nit from PR aws-samples#681 self-review. Relates to aws-samples#306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(aws-samples#306): paginate registry scan (fix 404 on live workspaces) + purge revoke-first + drop dead mapping cleanup B1 (blocking): the registry lookup used `Limit: 1` on a FILTERED Scan. DynamoDB applies FilterExpression after evaluating `Limit` items, so a single-row limit examined one arbitrary row, filtered it out, and 404'd a live workspace whenever the registry held >1 row (the normal shared-stack state; guaranteed after the first soft-revoke). Drop `Limit` and paginate to completion, matching jira-webhook-processor.ts / shared/linear-issue-lookup.ts; document the small-table assumption. Fix the test double so the registry-scan router honors Limit/ExclusiveStartKey and applies the filter to the examined slice (this is why B1 was invisible), and add a two-active-row regression with the target on the second page (fails before, passes after) plus a follow-LastEvaluatedKey assertion. B2 (blocking): mapping cleanup was a provable no-op reported as success — LinearProjectMappingTable rows carry no workspace id (onboard-project writes none), so the `linear_workspace_id` filter matched zero rows always while the CLI printed "✓ 0 project mapping(s) removed". Take the reviewer's cheapest honest fix: remove the mapping-cleanup path entirely — deleteWorkspaceProjectMappings + its call, the `--keep-mappings` flag, the projectMappingTable.grantReadWriteData grant, the LINEAR_PROJECT_MAPPING_TABLE_NAME env, and the 30s timeout bump (reverted to 10s matching siblings — N3). CLI output + LINEAR_SETUP_GUIDE no longer claim mapping cleanup; mappings are removed by project id. Schema follow-up (record linear_workspace_id at onboard time) to be filed separately. B3 (blocking): on `--purge`, markSecretDeletionFailed early-returned, so a failed DeleteSecret after the row was deleted leaked the OAuth secret with no durable record. Reorder to revoke(Update)→DeleteSecret→delete-row(purge only, after secret confirmed gone), so the marker always lands and fail-closed holds. Drop the `purged` special-case; correct the two backwards comments (:249-251, :283-285). Add a --purge marker regression + an ordering assertion. N2: pin the previously-vacuous construct tests — resolve RemoveWorkspaceFn via its unique DeleteSecret role grant, pin the DELETE method to the {slug} resource, and assert the DeleteSecret grant is bound to that role AND scoped to bgagent-linear-oauth-*. Closes aws-samples#306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * test(aws-samples#306): de-tautologize role-binding assertion + fix pagination/test comments + runbook markers (aws-samples#306) Addresses the 4 non-blocking approval nits from @isadeks: 1. Role-binding assertion (cdk/test/constructs/linear-integration.test.ts): findRemoveWorkspaceFn() now derives the remove-workspace role INDEPENDENTLY from the FUNCTION resource (its registry-only Environment signature + Role Fn::GetAtt), not from the DeleteSecret policy. The secret-prefix test asserts the DeleteSecret grant lands on THAT role, so mis-wiring the grant onto another role now fails the test (proven: moving the grant to linkFn makes `expect(roleRefs).toContain(role)` fail). No longer a tautology. 2. Pagination comment (cdk/src/constructs/linear-integration.ts): the bounded sequence now names the lookup phase (registry lookup → revoke → secret delete → optional row purge) and states the lookup scan is the only paginating phase, bounded by the registry's tens-of-rows scale. 3. Test prose (cdk/test/handlers/linear-remove-workspace.test.ts): :270 comment now says "Page 1: empty (no matching row) + a continuation key" to match `Items: []`, and the :262-265 preamble no longer describes stale routeDdb Limit behavior. Comment-only; test logic unchanged. 4. Runbook markers (docs/guides/LINEAR_SETUP_GUIDE.md + regenerated Starlight mirror): the manual-fallback section now names the durable markers (secret_deletion_failed / secret_deletion_error / orphaned_oauth_secret_arn) and the SECRET_DELETE_FAILED error code, pointing operators to the delete-secret fallback. Closes aws-samples#306 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(aws-samples#306): reconcile DELETE route with post-aws-samples#854 conventions after rebase Rebasing onto main (12c9b63) surfaced three collisions with work that merged after this branch was authored. None were textual conflicts — git merged the affected files cleanly and produced a build that failed. 1. `allowTestInvoke: false` on the DELETE integration. aws-samples#854 stripped the API Gateway console test-invoke Lambda permissions to reclaim CloudFormation resources under the 500-resource ceiling, and added a guard asserting none are emitted. This route predates that convention, so it re-introduced one and failed the guard 6x (once per compute_type x enableToolGateway variant). The two sibling routes in this same construct already pass the option; now all three agree. 2. Attributed-Lambda count 46 -> 47. The original a19f16d bumped 45 -> 46 for RemoveWorkspaceFn, but main independently reached 46, so `git rebase` dropped the commit as "patch contents already upstream" — identical text, different reason. The textual change survived; the intent did not. With this branch's extra Lambda the correct value is 47. 3. Solution user agent on the new handler's SDK clients. linear-remove-workspace.ts constructed `new DynamoDBClient({})` and `new SecretsManagerClient({})` directly, which drops the solution user agent (aws-samples#319). Now built through makeDocClient()/makeClient(), matching linear-link.ts and linear-webhook.ts. Also resolved the LINEAR_SETUP_GUIDE.md conflict from aws-samples#831: kept its new "Vault-managed workspaces" section and dropped the duplicated webhook/ uninstall sentence, which this branch had already relocated into the parent "Removing a workspace" section. Starlight mirror regenerated. Refs aws-samples#306 * fix(aws-samples#306): report vault-managed teardown as incomplete + settle the revoke race (aws-samples#681 B1, N1-N7, N10-N12) Addresses PR aws-samples#681 review feedback on `DELETE /v1/linear/workspaces/{slug}`. B1 (blocking) — the registry lookup could miss an existing workspace. `ScanCommand` was issued with `Limit: 1` plus a `FilterExpression` on `workspace_slug`. DynamoDB applies `Limit` to items *examined*, not items matched, so a filtered scan can legitimately return an empty `Items` array together with a `LastEvaluatedKey` while the target row sits a page deeper. On any table with more than one row the handler therefore 404'd on workspaces that existed. The scan now pages via `ExclusiveStartKey` until the row is found or the keyspace is exhausted, capped at `MAX_SCAN_PAGES` (20) so a pathological table cannot pin the Lambda until timeout — the cap is a 500, not a silent 404, because "we gave up looking" is not "it is not there". `ConsistentRead: true` was added so a removal issued straight after a `linear setup` reads its own write. The paging fix widens, but does not close, a TOCTOU: two concurrent DELETEs could both find the same `active` row and both report success. The revoke `UpdateCommand` now carries `ConditionExpression: '#status = :active'`, so exactly one caller wins; the loser's `ConditionalCheckFailedException` maps to 404 `WORKSPACE_NOT_FOUND` and, critically, does *not* proceed to delete the OAuth secret out from under the winner. N1/N2 — `secret_deleted: boolean` becomes `secret: 'deleted' | 'absent' | 'not_applicable'`. A boolean conflated two very different outcomes: "there was a secret and it is gone now" and "there was never a Secrets Manager secret because this workspace is vault-managed". The latter means teardown is *not* finished — an AgentCore OAuth2 credential provider survives outside CloudFormation, still holding the Linear client secret and a live, self-refreshing grant, and `cdk destroy` will not remove it. The response now echoes `provider_name` for those rows and the CLI prints the exact `aws bedrock-agentcore-control delete-oauth2-credential-provider` follow-up. `not_applicable` is deliberately narrow (`providerName && !oauthSecretArn`): `bgagent linear setup` writes `oauth_secret_arn` unconditionally, so a vault row that also carries an ARN really did have a secret and reports `absent`. N3 — the "removes everything" claims in the CLI prompt and the setup guide were wrong in the vault case. Both now say what is *not* removed, and the pre-confirmation prompt warns before the destructive action rather than only disclosing it afterwards. N7 — the secret delete falls back to the deterministic `bgagent-linear-oauth-<slug>` name when the row records no `oauth_secret_arn`, so a partially-written row does not orphan its secret. `secretsmanager:DeleteSecret` is granted over that name prefix (`linear-integration.ts`), and `SecretId` accepts a name or an ARN, so the by-name call is permitted. The prefix is verified identical in all four of its co-definitions. N11/N12 — `revoked_reason` is now `admin_removed`, not `vault_consent_required`. That distinction is load-bearing: `vault_consent_required` is the one revoked reason the OAuth resolver re-probes instead of refusing, so reusing it here would let a later successful vault probe un-latch a workspace an operator deliberately removed. The vocabulary lives in `LinearRevocationReason` (exported from `shared/linear-oauth-resolver.ts` as a **type only**) and the writer declares its own constant. `import type` is erased before esbuild, so the removal handler takes no runtime dependency on the resolver — a value import would pull SNS alerting, the resolver's DDB/Secrets Manager clients and the token-refresh path into this Lambda's bundle, and would land the handler in the `agent.test.ts` minting-handler census whose entire value is that such an import is a test failure rather than a production 401. N4/N5/N6/N10 — `LINEAR_WORKSPACE_REGISTRY_TABLE_NAME` is read once at module scope and validated in-handler, so a misconfigured deployment 500s with a named cause instead of an opaque SDK error; the response body is typed by a module-local interface applied with `satisfies`; the 404-on-lost-race path logs a WARN naming `oauth_secret_arn`; the 403 wording no longer implies the workspace exists. Not included, per review scope: N8 (409 on duplicate active rows for one slug — currently first-match-wins) and N9 (collapsing the 403 existence oracle to 404). Both change API semantics and belong in their own issues rather than a bugfix PR. N9's wording half is done here. Tests: 24 in the handler suite (by-name delete, `not_applicable` + `provider_name` echo, vault-row-with-ARN => `absent`, lost race => 404 with no secret delete, non-conditional update failure => 500, page cap => 500 after exactly 20 scans, `--purge` delete failure => 500 with the revoke landed, marker-write failure still surfacing `SECRET_DELETE_FAILED`, and a missing-table-name case in its own module registry). Full suites green: cdk 4579/4579 (216 suites), cli 941/941 (63 suites), docs 77 pages, drift-prevention clean, jira-forge-app 11/11. The agent pytest step of `//agent:quality` was NOT run: this diff contains no Python, and that suite writes stray commits when run from a worktree lacking the aws-samples#856 git-config isolation. `//agent:lint` and `//agent:typecheck` were run instead, both clean. Refs aws-samples#306, aws-samples#681. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(aws-samples#306): correct the review-item numbering in 253de07's commit message Empty commit. No code, test, or docs change — this exists only to correct the record, because `253de07f`'s message cannot be amended without force-pushing a shared PR branch. `253de07f` labels its sections with a B1/N-numbering that does NOT correspond to review 5181793802 on PR aws-samples#681. Every change it describes is a change that was actually made, and the descriptions are accurate; only the labels are wrong. Read the labels below, not the ones in that message. Root of the confusion: PR aws-samples#681 has had two "B1"s. July's round 4807516158 had B1 = the `Limit: 1` filtered-Scan bug, fixed back then in `0d006b04`. The current round 5181793802 has B1 = vault-managed teardown reported as complete. `253de07f`'s message narrates the already-landed July fix under the current round's B1 heading, which makes the pagination code read as new work when it predates the commit. Authoritative mapping of review 5181793802 to what `253de07f` changed: B1 vault-managed teardown reported as complete. `secret_deleted: boolean` -> `secret: 'deleted' | 'absent' | 'not_applicable'`, response echoes `provider_name`, CLI prints the `delete-oauth2-credential-provider` follow-up, prompt warns before the destructive action, guide updated. (253de07's message calls this "N1/N2".) N1 `ConsistentRead: true` on the lookup scan + `ConditionExpression: '#status = :active'` on the revoke, so the loser of a race 404s and never reaches `DeleteSecret`. (253de07's message folds this into its "B1" section.) N2 pagination-rationale cross-references corrected: dropped `jira-webhook-processor.ts`, cited `shared/jira-tenant-registry.ts:32-46` as the genuine precedent and as the `ConsistentRead` precedent, kept `linear-issue-lookup.ts:131-136` relabelled a counter-example, and replaced "paginate to completion" with "until a match or key exhaustion". (253de07's message does not label this at all.) N3 the `status != 'active'` claim qualified in five places, and the removal now writes the distinct `revoked_reason: 'admin_removed'`. (253de07's message splits this across its "N3" and "N11/N12".) N4 `DeleteSecretCommand` input asserted exactly: `SecretId` plus `ForceDeleteWithoutRecovery: true`. (253de07's message lists this under "N4/N5/N6/N10" without detail.) N5 all three `JSON.stringify(...).toContain('revoked')` assertions replaced with value-level `[':revoked'] === 'revoked'` and `[':uid'] === ADMIN`. N6 the three untested failure paths covered: revoke rejecting (500, no `DeleteSecret`), `markSecretDeletionFailed` rejecting (still `SECRET_DELETE_FAILED`), `--purge` `DeleteCommand` rejecting (500 with the revoke asserted landed). N7 `MAX_SCAN_PAGES = 20`, returning 500 rather than 404. (253de07's message uses "N7" for the by-name secret-delete fallback, which was not a numbered review item.) N8 NOT in this commit. Filed as aws-samples#883 (409 on duplicate active rows). N9 wording half only. The behavioural half is filed as aws-samples#884. N10 manual-fallback snippet reordered to revoke-first with `--condition-expression '#s = :active'`, plus the note that re-running `remove-workspace` on an already-removed workspace returns 404. N11 module-local `RemoveWorkspaceResponseBody` applied with `satisfies` at the return. (253de07's message lists this under "N4/N5/N6/N10".) N12 `WORKSPACE_REGISTRY_TABLE` is plain `string | undefined`, no non-null assertion, validated once in-handler. (253de07's message lists this under "N4/N5/N6/N10".) Also not a numbered review item, and therefore unlabelled rather than mislabelled: the by-name `bgagent-linear-oauth-<slug>` fallback for the secret delete when the row records no `oauth_secret_arn`. The full mapping with reasoning is in the PR comment: aws-samples#681 (comment) Refs aws-samples#306, aws-samples#681, aws-samples#883, aws-samples#884. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: scottschreckengaust <345885+scottschreckengaust@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Sphia Sadek <isadeks@gmail.com>
Verification could not tell a workspace's own signing secret from a copy of another workspace's, because the only available signal was value equality against the stack-wide secret — and that signal is wrong. A healthy single-workspace install also holds a secret equal to the stack-wide copy, since the first install stamps the same real secret into both slots. Rejecting on equality would 401 exactly the deployments that are safe. So ownership is now recorded by provenance instead: `webhook_secret_owned` on the registry row, written from where the value came from. `setup` sets it when the operator supplied the secret for this workspace or when a stored `lin_wh_` value differs from the stack-wide one. `update-webhook-secret` sets it too — that is the remediation path, and without it an operator could paste the correct secret and see no change in how the workspace is treated. Only a literal `true` is read back as proof. Absence means "not proven", which is what every row predating this field is, and is deliberately not the same as "proven not owned". `update-webhook-secret` gains `--stack-name` so it can reach the registry, and fails loudly if the secret write succeeds but the provenance write does not — silence there would leave the operator believing a fix landed while the state verification reads is unchanged. Two things found while building this, both worth knowing: `mirror-stackwide` — the branch that copies the first workspace's secret into an additional workspace — is already unreachable from `setup` on current main. #831 added an up-front prompt that throws when neither a supplied nor a legitimately stored secret exists, which is the only way into that branch. The refusal added here is therefore a safety net, not a live fix; it is extracted as `assertMirrorIsSafe` and unit-tested directly, because a guard reachable only through dead code is otherwise an untested guard. The remaining exposure is rows written by older releases and not re-run since. The provenance derivation cannot currently evaluate false, so mutating it to a literal `true` leaves every test passing. That is semantic equivalence under the current preconditions rather than a test gap — there is no reachable input that distinguishes them. It is kept as a derivation because it becomes load-bearing if that up-front prompt is ever relaxed, and the resulting failure would be silent.
…ign (aws-samples#879) (aws-samples#880) * fix(security): merge vault channel_metadata by spread, not Object.assign (aws-samples#879) `Object.assign` copies via [[Set]], which invokes the `__proto__` setter, so the pattern sits one refactor away from a prototype-pollution sink. Object spread defines own properties instead, where the same key lands inert. Not reachable today — `vaultMetadata` returns a literal with two hard-coded keys — but semgrep rates javascript.lang.security.insecure-object-assign as Blocking, and `security:sast` runs whole-repo in the pre-push hook while security-pr.yml runs only the ranged gates. So this one line passed PR CI on aws-samples#831 and then rejected every contributor's `git push`, on every branch, for as long as it sat on main. Spread rather than explicit keyed writes because `vaultMetadata` is the single place that says which vault fields a task carries; restating them at this call site is the silent field-drop the helper exists to prevent. Tests — two source-level guards, failing for different reasons: * The existing structural check keys off the literal-entry form (`linear_workspace_slug: resolved.workspaceSlug,`), so it never fired on this builder at all: the one path `vaultMetadata` was written for was the one path its own guard never covered. Extended to the assignment form, scoped by dedent because an assignment has no literal to terminate. * A ban on `Object.assign` onto a metadata object. Reverting this change is behaviour-preserving, so the existing behavioural test stays green through it — a security property that lives in *how* the copy happens needs a structural check. Mutation-verified: restoring `Object.assign` reds both new guards and neither the behavioural test; deleting the merge reds the assignment-form guard and the behavioural test but not the Object.assign ban. `mise run build` fails locally at `//cdk:synth:quiet` on an unrelated host IAM gap (`ec2:DescribeAvailabilityZones` denied to the local role, needed for the AgentCore AZ context lookup). cdk compile, eslint, and 215 suites / 4542 tests pass. Closes aws-samples#879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(cdk): drop the dead workspace-id write and correct the stale guard docstring (aws-samples#880 review) Three review items on the handler, all non-blocking. 1. Deleted `channelMetadata.linear_workspace_id = workspaceId;`. It was dead, and this PR is what killed it: the object literal above already sets that key to the same value from the same variable, and `vaultMetadata` does not emit the key, so the spread cannot clobber it either. Confirmed by enumerating every writer of the key, not by reading the happy path. 2. Corrected `vaultMetadata`'s docstring. It still claimed the source-level check "keys off the object-literal form and cannot see a builder that assigns onto an existing object" — the premise of this PR, and false since the assignment-form guard landed. It now describes three checks: the literal form, the assignment form, and the behavioural one. 3. Trimmed the rationale at the call site from 24 comment lines to 13. Kept the two load-bearing claims — spread rather than `Object.assign` because [[Set]] invokes the `__proto__` setter, and spread the helper so a newly added vault field cannot be silently dropped — plus the recorded-subject note. The guard archaeology and the pre-push history now live in the commit trail and aws-samples#879, where they cannot drift out of sync with the code. Refs aws-samples#879. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(cdk): close two holes in the vault-metadata source guards (aws-samples#880 review) Both guards now match code, never prose, via a shared `isCommentLine`, and the assignment-form guard requires the merge to hit the same object the trigger wrote to. `assignment-form builders also carry the vault fields` The review's diagnosis was right: the accept window is far wider than it looks. The trigger sits at the shallowest indent of `if (WORKSPACE_REGISTRY_TABLE)`, so the scan ran ~45 lines to the end of that block — a `...vaultMetadata(resolved)` in any later sibling statement satisfied a builder that merged nothing into its own object. The suggested remedy is not applied as written, deliberately. Breaking on the first line indented `<=` the trigger reds the currently passing case on a comment at the same indent, and more fundamentally the real spread sits one level DEEPER, inside `if (resolved.providerName)`, because the merge is conditional while the slug write is not. Same-scope is therefore not an invariant this code can assert; same-object is, and it closes the same hole. The dedent break is kept as the scope exit and now precedes the accept check, which is stricter than before — the old order accepted a dedented spread. `no metadata builder merges via Object.assign` Regex unqualified over this file. `/Object\.assign\s*\(\s*\w*[Mm]etadata\b/` required the first argument to be a bare identifier containing "metadata", so `Object.assign(md, …)` and `Object.assign(row.channelMetadata, …)` reintroduced the sink without redding the test — `\w*` cannot cross a `.`. Scope stays this one handler on purpose; the class-level net is the whole-repo semgrep gate that `security-pr.yml` omits, tracked in aws-samples#235 rather than bolted on here. Comment-stripping is load-bearing for the vaultMetadata guard, whose `includes()` test was satisfied by a comment merely mentioning the spread (mutation M3 below). For the `Object.assign` ban it is defensive rather than required: the handler's three prose mentions all write the name in backticks, so `\s*` never reaches a `(`. Incidental punctuation, not a property worth depending on. Mutation-verified rather than asserted. Each row was applied to the handler, the named guards observed red, then reverted and the source re-verified by content: M1 merge → `Object.assign(channelMetadata, …)` both guards red M2 merge → `Object.assign(md, …)` both guards red (before: BLIND) M3 merge deleted, comment still names the spread assignment guard red (before: BLIND) M4 merge moved to a sibling block, other object assignment guard red (before: BLIND) M2, M3 and M4 are the differentials — all three passed before this change. Gates: `//cdk:test` 215 suites / 4552 tests green; this file 61/61; `//cdk:eslint` clean with no `--fix` mutations; `//cdk:compile` clean; `//cdk:synth:quiet` green once the AZ context is pinned (it otherwise fails closed on a local role lacking `ec2:DescribeAvailabilityZones`, as documented in DEPLOYMENT_GUIDE.md — unrelated to this change). Note for anyone rerunning: `-t` filtering on this file is unreliable and not a regression. Two behavioural tests in this describe have no `beforeEach` of their own and depend on `probeLinearIssueContextMock` being armed by the first describe's, so any filter that skips it fails them on pristine HEAD too. Run the whole file. Refs aws-samples#879, aws-samples#235. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: scottschreckengaust <345885+scottschreckengaust@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Sphia Sadek <isadeks@gmail.com>
… the delivery (aws-samples#917) * feat(linear): record the owning workspace on project mappings The Linear project-to-repo mapping table is keyed on `linear_project_id` alone and carries no workspace attribute, so nothing ties a mapping to the tenant that owns it. The webhook processor looks the repository up from the body-supplied `projectId` with no way to check it against the workspace whose signature the delivery arrived with, which lets any onboarded workspace name another workspace's project and steer an agent task at that workspace's repository. This records the missing value so a later change can enforce it; no read path changes behaviour yet. `onboard-project` now resolves the owning workspace by asking Linear which onboarded workspace's own token can see the project, rather than accepting it as a flag. The resolution is what the enforcement path will check deliveries against, so a typo'd flag would durably write the cross-tenant mapping the check exists to prevent — and `organization.id` is read from the same authenticated response that resolved the project, not from the workspace we assumed we were asking. `--slug` narrows the search; `--workspace-id` records an owner without verifying it, for when the Linear API is unreachable. `backfill-project-workspaces` fills in mappings that predate the field, one pass per workspace rather than one lookup per row, and paginates the project listing (`list-projects` stops at 100, which for a backfill would silently leave the overflow unresolved). The update is conditional on the row still existing and still having no workspace id, so it neither resurrects a deleted mapping nor overwrites a concurrent `onboard-project`. A project id claimed by two workspaces is skipped and named rather than assigned to whichever answered first. `platform doctor` reports mappings with no owning workspace, capped at ten named ids, so an operator can tell whether the backfill is finished before enforcement is turned on. * feat(linear): check a project mapping against the workspace that sent the event The processor resolved the target repository from the body-supplied `projectId` alone. The mapping table is keyed on that id with no workspace attribute, so a workspace holding its own valid signing secret could sign an event with its own `organizationId` while naming another workspace's project, and the processor would run an agent task on that workspace's repository. Nothing about that attack needs a shared or stack-wide secret, which is why signature-level fixes do not reach it. Now the mapping's `linear_workspace_id` is checked against the event's `organizationId` and a mismatch drops the delivery. Dropped rather than answered. The reply would be posted into the sender's own workspace, and "that project belongs to someone else" both confirms the project exists and tells a prober the attempt was seen. The log line carries both ids and is the diagnostic surface instead. A mapping with no owning workspace recorded still proceeds, and warns. Rejecting those on deploy would break every install that has not run `backfill-project-workspaces` yet; turning it into a rejection is a later step, gated per environment once `platform doctor` reports the backfill clean. Both rejection tests were confirmed to fail with the check disabled. The no-workspace-id case originally passed either way — an event with no `organizationId` never creates a task regardless, because it fails later at attribution — so it asserts the guard's own log line instead, which is the only thing that distinguishes a drop here from a drop there. * feat(linear): record whether a workspace owns its webhook signing secret Verification could not tell a workspace's own signing secret from a copy of another workspace's, because the only available signal was value equality against the stack-wide secret — and that signal is wrong. A healthy single-workspace install also holds a secret equal to the stack-wide copy, since the first install stamps the same real secret into both slots. Rejecting on equality would 401 exactly the deployments that are safe. So ownership is now recorded by provenance instead: `webhook_secret_owned` on the registry row, written from where the value came from. `setup` sets it when the operator supplied the secret for this workspace or when a stored `lin_wh_` value differs from the stack-wide one. `update-webhook-secret` sets it too — that is the remediation path, and without it an operator could paste the correct secret and see no change in how the workspace is treated. Only a literal `true` is read back as proof. Absence means "not proven", which is what every row predating this field is, and is deliberately not the same as "proven not owned". `update-webhook-secret` gains `--stack-name` so it can reach the registry, and fails loudly if the secret write succeeds but the provenance write does not — silence there would leave the operator believing a fix landed while the state verification reads is unchanged. Two things found while building this, both worth knowing: `mirror-stackwide` — the branch that copies the first workspace's secret into an additional workspace — is already unreachable from `setup` on current main. aws-samples#831 added an up-front prompt that throws when neither a supplied nor a legitimately stored secret exists, which is the only way into that branch. The refusal added here is therefore a safety net, not a live fix; it is extracted as `assertMirrorIsSafe` and unit-tested directly, because a guard reachable only through dead code is otherwise an untested guard. The remaining exposure is rows written by older releases and not re-run since. The provenance derivation cannot currently evaluate false, so mutating it to a literal `true` leaves every test passing. That is semantic equivalence under the current preconditions rather than a test gap — there is no reachable input that distinguishes them. It is kept as a derivation because it becomes load-bearing if that up-front prompt is ever relaxed, and the resulting failure would be silent. * feat(linear): refuse a signing secret that cannot attest which workspace sent a delivery The processor routes from identifiers in the request body, so verification has to establish WHICH tenant sent a delivery — not merely that the sender knows a secret this stack accepts. Two secrets fail that test: the stack-wide one, which is bound to no workspace, and a per-workspace one that is a copy of another workspace's, which older releases could install. Both are now refused, and both only where they can actually cross a boundary: - `verifyLinearRequestForWorkspace` gains a `shared-secret` outcome. A matching signature on a row that does not record the secret as its own is rejected when another workspace is active. Fatal like `mismatch`, for the same reason — falling through would re-admit it via the stack-wide path. - The receiver skips the stack-wide fallback entirely on a multi-workspace stack. This is what supersedes sole-tenant binding rather than implementing it: with the fallback gone under multi-tenancy, that path survives only where there is one tenant, and there it cannot name the wrong one. - `verified_via_stack_wide` is forwarded to the processor, which replaces the body's `organizationId` with the sole active workspace and drops the delivery when there is no single answer. The field is overwritten on the payload rather than threaded as a parameter because the workspace id is read from about six places downstream; passing it alongside would leave every one of them a site where the claimed value could still be picked up. Single-workspace installs are untouched by design. Every check consults the active workspace count first, and with one tenant a tenant-less secret still identifies the only tenant there is. That also makes an absent provenance flag — the state of every row written before it existed — harmless on the installs where it is normal. `countActiveLinearWorkspaces` saturates at 2, since no caller needs the true total, and answers 1 when the registry cannot be read. That is deliberately the permissive direction: these checks harden a signature that has already verified, so a DynamoDB throttle must not become a stack-wide outage. No IAM change. `grantReadData` already covers `dynamodb:Scan` for both the receiver and the processor on the registry table. All three guards were confirmed to fail with the check disabled. One test needed fixing first: the multi-workspace fallback case passed because the harness shrank the workspace count along with the absent registry row, so the receiver had only ever seen one workspace. * feat(linear): record provenance for secrets that were never mirrored Enforcing per-workspace secret ownership needs the provenance flag to already be set, and it is set only by `setup` and `update-webhook-secret`. On an existing multi-workspace stack no row has it, so turning enforcement on rejects every workspace at once — including workspaces that genuinely own their secret and are being broken for nothing. On the dev stack that is 3 of 3. `backfill-secret-provenance` closes the gap for the workspaces where ownership can actually be established. `mirror-stackwide` can copy nothing except the stack-wide value, so a stored secret that DIFFERS from it was never mirrored and is therefore owned. That conclusion cannot be wrong, which is what makes it safe to write. The converse is deliberately not used. A secret EQUAL to the stack-wide value is equally consistent with a healthy single-workspace install, whose first `setup` stamps the same real secret into both slots — so `inherited` withholds a verdict rather than asserting "not owned", and only `own` is recorded. This is the same asymmetry the earlier commit's doc comment warns about, now expressed as code: the direction that grants is sound, the direction that would reject is not. Reading the stack-wide secret is fatal on failure rather than treated as "not set". Continuing with undefined would classify every workspace as owning its secret, since nothing can equal a value that was never fetched — recording exactly the shared secrets this command exists to withhold. `platform doctor` reports the same population, gated on the active workspace count so it matches the enforcement path: on a single-workspace stack an unrecorded provenance is the normal state for every pre-existing row and carries no risk, so it passes with a note rather than warning. Both directions of the classifier were confirmed to fail when mutated — treating `equals stack-wide` as owned, and treating an empty stored secret as present. * fix(linear): do not blame stale mappings for a workspace we could not query The first live run of `backfill-project-workspaces` hit two workspaces whose Linear grants had expired. Both returned 401, so every mapping they own came back unresolved — and the report concluded those mappings "name projects no onboarded workspace can see" and told the operator to re-onboard or remove them. Four valid rows, and the suggested remedy was to delete them. An unresolved mapping means opposite things depending on whether every workspace was actually reachable, so the conclusion now branches on that. Skipped workspaces are tracked wherever the loop gives up on one — no token, an API error, or a response with no organization id — and when any exist the report leads with "Do NOT delete these yet", names them, and points at restoring their access first. The delete-them wording now appears only when every workspace was queried successfully. Verified by re-running against the dev stack, where the output now names demo-abca and maguireb as the reason rather than implicating their projects. The guard is asserted at source level: the branch sits inside a Commander action that would need the whole AWS and Linear surface stood up to reach, and the behavioural proof is the live run. The test pins that the unconditional wording cannot come back. * fix(linear): refuse to half-repair a workspace when the registry cannot be found `update-webhook-secret` writes two things: the signing secret, and the registry flag recording that the workspace owns it. Verification reads the flag, so without it the repair changes nothing observable — the workspace keeps 401ing. The registry write was wrapped in `if (registryTableName)`, so an operator who defaulted or mistyped `--stack-name` got the secret written, no flag, and a green "✅ Updated webhook signing secret". That is precisely the silent-miss the comment directly above the block claims to prevent, and it would send someone chasing a 401 whose cause was in the output they already saw. Now it refuses, names the stack it looked in, says the secret was preserved, and points at `--stack-name`. * refactor(linear): drop migration tooling for a state one deployment is in The fix itself is 257 lines of cdk/src. The CLI around it had grown to 926 — mostly tooling to migrate stacks into the new model. Reviewed against how many deployments actually need migrating, two pieces did not earn their weight. `backfill-project-workspaces` is gone, with its paginated project lister. A single-workspace install does not need it at all: with one tenant every mapped project belongs to that tenant, so a body-supplied projectId cannot steer a task across a boundary. Its real audience was "a stack with two or more workspaces AND pre-existing mappings", which is one deployment today. It was also the only part of the branch that needed live Linear tokens, and the only part that has already produced a defect — a report that told an operator to delete four valid mappings because two workspaces had expired grants. The doctor check that reported those mappings stays, because knowing about them is still worth an operator's attention. Its remedy is now "re-run onboard-project before you add a second workspace", and it says plainly that the state is harmless while one workspace is active. `assertMirrorIsSafe` is gone too. It guarded a branch that is unreachable from `setup`, and the value that branch would produce is no longer trusted on its own anyway — the receiver rejects a secret not recorded as the workspace's own once a second workspace is active. The comment there now describes what the code does rather than the refusal that used to follow it, and says what would need to change if the up-front prompt is ever relaxed. `backfill-secret-provenance` stays. This is the one place doing it by hand is actively dangerous rather than merely tedious: the safe rule holds in one direction only, and setting the flag on a workspace that holds the shared secret silently reopens the hole. It also runs with no Linear dependency at all, which is why it is the piece that already works on a stack with two dead grants. 935 tests green. The classifier's mutation was re-run after the removal, since it sat between two deleted blocks and had to be restored. * test(linear): note why the fake UUIDs avoid twelve trailing digits The org-id fixtures use hex letters in the final group because a UUID ending in twelve consecutive digits matches gitleaks' `aws-account-id` rule, which failed CI's range-scoped secret scan on values that hold no secret. The values themselves were corrected in place across this branch's history — fixing them in a follow-up commit left them in the commit that introduced the file, and the scan is range-scoped over every commit in the PR. Recorded as a comment so the constraint survives the next person editing these constants. Fixed in the fixture rather than allowlisted in `.gitleaks.toml`: widening a shared security rule to accommodate a test constant is the wrong direction. * feat(linear): scope @bgagent comment targets to the sending workspace Comment triggers find their target by issue id, through the orchestration or the newest task on the issue. Match that target's recorded workspace (the orchestration's credentials_ref, or the task's channel_metadata.linear_workspace_id) against the workspace the delivery came from, and act only when they match. This brings the comment path in line with the project-mapping workspace check added earlier on this branch. The task lookup now returns linear_workspace_id from channel_metadata, which the LinearIssueIndex projection already includes. Test fixtures now use a single workspace throughout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(linear): end-to-end coverage for webhook workspace binding Runs a delivery through the real receiver and hands the receiver's processor invoke to the real processor, which runs the real token resolver, user lookup, orchestration store and createTaskCore. All tables and secrets live in one stateful fake shared by both handlers, so the receiver's workspace count and the processor's binding read the same registry. Only the modules that call Linear's API are mocked, and network access is refused. Each case asserts whether a task record was persisted, and for which repository, platform user and workspace. Covered: - several workspaces: signature and workspace rules for owned, stack-wide-only, shared and unregistered secrets, a tampered body, a revoked workspace, project ownership, and actor scoping - one workspace: the stack-wide fallback binds to the only workspace; lingering mappings from inactive workspaces are not served; a second workspace onboarded inside the receiver's count-cache window is caught by the processor - @bgagent comments on epics, sub-issues, plain issues and retries act only on the sending workspace's work, with matching-workspace controls Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): make doctor's Linear verdicts match the workspace count and admin removals Found while live-testing this branch on a multi-workspace stack: - An admin-removed workspace (`remove-workspace`, revoked_reason=admin_removed) was reported as a REVOKED authorization: doctor failed and told the operator to re-authorize a workspace they had removed on purpose. It is now `disabled`, the state that already exists for intentional drops, and the pass line no longer counts it as authorized. - The unowned-project-mapping check said "harmless while one workspace is active" whatever the count. With more than one active workspace the webhook path admits an unowned mapping with only a warning, so the check now reads the registry: it fails above one workspace, keeps the "harmless" wording for exactly one, and makes no claim when the registry cannot be read. - A processor comment named `backfill-project-workspaces`, which this branch removed; it now names `onboard-project`. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: bgagent <bgagent@noreply.github.com>
Summary
Closes #809
Closes #812
Mints Linear OAuth tokens through the AgentCore Identity Token Vault instead of storing a long-lived credential, and detects/latches/announces a revoked channel credential instead of surfacing it as a failed user task.
Both issues ship together because the code is interdependent: #812's typed
VaultTokenResultis what the vault resolver returns, and #809's recorded vault subject flows through #812's revocation recorder. Splitting them would mean reordering commits that touch the same files for a boundary a reviewer does not benefit from.Flag-gated on
--context enableLinearIdentityVault=true(default off). With the flag off the synthesized template contains none of it — asserted by test.What changes for an operator
bgagent linear setup <slug>is now the only Linear onboarding command, and it needs one consent.vault-setup,--hostedand--localhostare gone; there is nothing left to chooseapp-templateresolves the URLs it used to demand: the consent page from a stack output, the vault callback from the provider, and the webhook endpoint from the configured API URLThe design decision worth reviewing
The vault subject is derived from the workspace slug, not its organization UUID, and recorded on the registry row as
vault_user_id.The UUID is only knowable after a token exists. Deriving the subject from it therefore forces two consents — one to learn the organization, a second to bind the vault. A slug-derived subject is known before consent, which is what makes single-consent onboarding possible. Both resolvers prefer the recorded value and fall back to the derived form for workspaces onboarded before it was stored, so existing vault installs keep minting under the subject their grant is actually under.
Two deliberate divergences from ADR-016 P1, both noted in the ADR:
bgagent-linear-oauth-<slug>) rather than one shared provider with per-workspace subjects. Keeps each workspace's grant and rotation isolated. Cost: each workspace adds one redirect URI to the Linear app.Known limitations, stated plainly
cdk/src/stacks/agent.tsnow refuses a deployable configuration while quoting a figure that is no longer true. The vault was always wired for MicroVM — platform config carries the workload name, the guest execution role holds the mint grant. Note the covering test asserts the throw, so it pins the guard rather than detecting the headroom; it did not and could not go red when the room appeared. Removing the guard, and addingenableLinearIdentityVaultto the resource-budget matrix fix(cdk): reclaim resource and byte headroom under both CFN ceilings (#852) #854 introduced so the combination is genuinely guarded rather than merely permitted, is the follow-up.resourceOauth2ReturnUrl, registered on the AgentCore workload identity's allowlist, which the provisioning custom resource rewrites on every deploy; the CLI reads the current value from theLinearVaultConsentUrloutput rather than caching one. So if the domain changes, nothing breaks. What Linear holds on that path is AgentCore's own callback (…/identities/oauth2/callback/<uuid>), which survives re-runs because a duplicate provider name updates in place instead of minting a new id. The instability matters only on the Secrets-Manager (non-vault) leg, where the page really is Linear'sredirect_uriand a changed domain means adding the new URI to the app — both variants of that leg print the warning, and Linear accepts several URIs so the stale entry is harmless. A stable custom domain, or serving the page from the existing API Gateway, would retire even that.Live verification
End-to-end on a real Linear workspace, with the agent-authored PR as the artifact.
Scope caveat, stated because it changes what this proves: the workspace was a FRESH install, onboarded by the merged
setupcommand in one pass. So the vault mint, the consent flow and the revocation latch are verified — but the path a workspace takes when its Secrets Manager bundle holds no grant and was not written by this flow is covered by unit tests only. Re-verification on a genuinely fresh vault workspace is what takes this out of draft.actor=appagent token (…@oauthapp.linear.app), not a user tokenstatus=revoked, one real SNS message, and zero duplicates on repeat eventsDefects the live run caught that unit tests could not
Recorded because each is a class, not a one-off:
Limit: 1on a filtered Scan reported an onboarded workspace as absent —Limitcaps items read before the filter.ValidationException, notConflictException, for a duplicate name. Broke re-runs, and would have broken the next deploy when the custom resource re-issued Create.GetWorkloadAccessTokenForUserIdauthorizes against the workload-identity directory, not the named identity;GetResourceOauth2Tokenagainst the token vault. Unit tests asserted the ARNs I had chosen, so only a live call could surface it.customParametersare part of the vault's cache key. Resolving without the consent-time set reports "needs consent" against a live grant, silently degrading every caller to Secrets Manager.invalid_request, not the RFC'sinvalid_grant, with the detail inerror_description. The revocation branch had never executed.bgagent linear setupcommand twice on the vault path, and a.filter(line => line !== '')intended for one conditional entry stripped every blank-line separator, collapsing the message into a single block. Both were green undertoContain, which cannot see a duplicate or a missing paragraph break; the assertions now pin the command count and the separators, and were mutation-checked against the old rendering.Test plan
mise run buildgreen — CDK 4411 / 210 suites, CLI 867 / 59 suites, agent 1762, docs build + link check, synthsecurity:secrets,security:deps,security:retire,security:sastcleanUpdateWorkloadIdentityauthorizes against the bareworkload-identity-directory/default, so it cannot be scoped to one identity; these actions do a multi-resource check and IAM names only the first resource it fails, so three successive deploys each surfaced a differentAccessDenied. Final grant is all four lifecycle actions on…-directory/defaultplus…-directory/default/workload-identity/*— still narrower than the original account-wideworkload-identity-directory/*, but the consequence is recorded in the construct: IAM cannot fence off the cross-stack allowlist clobber; the stack-derived NAME is what does. Verified by invoking the provisioning handler with an idempotent Update (previouslyAccessDenied, now clean), then confirming the return URLs were unchanged and the vault still mints a workingactor=apptoken.PINNED_LOG_DELIVERY_BY_STACKwhile both dev accounts had moved to library-named log-delivery resources. ADeliverySourceis unique per(resource ARN, log type)account-wide and the runtime ARN does not change, so a pinned id creates a second source for the same runtime and CloudFormation rolls the whole stack back withAlreadyExists. That is fix(agent): log-delivery pin table is keyed by stack name but holds per-account state, so it breaks deploys it was meant to protect #703/fix(agent): stop hard-coding log-delivery logical ids #705; dev was deployed from an integration branch carrying its pin removal, andcdk diffconfirmed zero log-delivery churn before deploying.Two pre-existing gate failures are unrelated to this branch:
security:sast:maskingreports on 49 files including lines that already carry justifiednosemgrepcomments (findings in files touched here are annotated), andsecurity:gh-actionsreports one medium in.github/workflows/auto-approve.yml, untouched here.